17장. 확장 함수와 확장 프로퍼티
우리는 String, List, Int 같은 타입을 매일 씁니다.
그런데 이런 타입에는 “내가 원하는 기능“이 늘 있는 것은 아닙니다.
예를 들어 “이 문자열이 올바른 이메일인가?“를 확인하고 싶다고 해 봅시다.
String에는 그런 함수가 없습니다.
이럴 때 자바에서는 보통 StringUtils 같은 도우미 클래스를 따로 만들었습니다.
하지만 코틀린에는 더 자연스러운 방법이 있습니다.
바로 이 장의 주인공,
확장 함수(extension function)와 확장 프로퍼티(extension property)입니다.
기존 타입에 마치 원래 있던 기능처럼
새 함수와 새 값을 덧붙이는 방법을 배워 보겠습니다.
17.1 Extension Function: 기존 클래스에 함수 추가하기
확장 함수란 무엇인가
확장 함수(extension function)는 이름 그대로
“기존 타입에 함수를 확장해서 붙이는” 기능입니다.
내가 만들지 않은 타입,
심지어 코드를 고칠 수 없는 타입에도
새로운 함수를 추가할 수 있습니다.
비유하자면 이렇습니다.
이미 완성된 스마트폰에
케이스를 끼워 새 기능(카드 수납, 거치대)을 더하는 것과 같습니다.
스마트폰 자체를 뜯어고치지는 않습니다.
먼저 간단한 예를 보겠습니다.
String에 “마지막 글자를 돌려주는” 함수를 붙여 보겠습니다.
fun String.lastChar(): Char {
return this[this.length - 1]
}
이제 이렇게 쓸 수 있습니다.
val word = "kotlin"
println(word.lastChar()) // 출력: n
word는 평범한 문자열입니다.
그런데 word.lastChar()처럼,
마치 String에 원래 있던 함수처럼 불렀습니다.
이것이 확장 함수의 매력입니다.
수신 객체와 this
확장 함수 선언을 다시 뜯어보겠습니다.
fun String.lastChar(): Char {
return this[this.length - 1]
}
여기서 함수 이름 앞에 붙은 String. 부분이 핵심입니다.
이 String을
수신 객체 타입(receiver type)이라고 부릅니다.
말이 어렵지만 뜻은 간단합니다.
“이 함수를 어떤 타입에 붙일 것인가“를 정하는 자리입니다.
String.lastChar()는
“String에 lastChar라는 함수를 붙이겠다“는 뜻입니다.
그리고 함수 안의 this는
그 함수를 호출한 바로 그 값을 가리킵니다.
val word = "kotlin"
word.lastChar()
이때 this는 word,
즉 "kotlin"이 됩니다.
이 this를
수신 객체(receiver object)라고 부릅니다.
정리하면 이렇습니다.
- 수신 객체 타입 : 함수를 붙일 대상 타입 (
String) - 수신 객체 : 실제로 함수를 호출한 값 (
"kotlin")
참고로 this는 생략할 수도 있습니다.
멤버에 바로 접근하면 코틀린이 알아서 this로 이해합니다.
fun String.lastChar(): Char {
return this[length - 1] // this 생략 가능
}
확장 함수를 직접 만들어 보기
이번에는 조금 더 쓸모 있는 함수를 만들어 보겠습니다.
Int에 “짝수인지” 알려 주는 함수를 붙여 보겠습니다.
fun Int.isEven(): Boolean {
return this % 2 == 0
}
사용은 이렇게 합니다.
println(10.isEven()) // 출력: true
println(7.isEven()) // 출력: false
10이라는 숫자 값에.isEven()을 바로 붙여 부르는 모습이
꽤 자연스럽습니다.
한 가지 더,
확장 함수도 매개변수를 받을 수 있습니다.
fun String.repeatWith(separator: String, times: Int): String {
val parts = List(times) { this }
return parts.joinToString(separator)
}
println("hi".repeatWith("-", 3))
// 출력: hi-hi-hi
여기서 this는 문자열 "hi"이고,separator와 times는 우리가 넘긴 값입니다.
Java 유틸리티 클래스와 비교하기
자바에서는 이런 기능을 어떻게 만들었을까요?
String에 함수를 직접 추가할 수 없으니,
보통 별도의 도우미 클래스를 만들었습니다.
이런 클래스를
유틸리티 클래스(utility class)라고 부릅니다.
값을 가지지 않고,
도우미 함수만 잔뜩 모아 둔 클래스입니다.
// 자바
public class StringUtils {
public static char lastChar(String s) {
return s.charAt(s.length() - 1);
}
}
사용할 때는 이렇게 씁니다.
// 자바
char c = StringUtils.lastChar("kotlin");
값이 앞이 아니라StringUtils.lastChar(값)처럼 뒤에 들어갑니다.
읽는 순서가 조금 어색합니다.
코틀린의 확장 함수는 이 순서를 뒤집습니다.
val c = "kotlin".lastChar()
값이 먼저 오고,
그다음에 하고 싶은 일이 옵니다.
우리가 말로 표현할 때와 순서가 같습니다.
“이 문자열의 마지막 글자”
이 자연스러운 순서가
확장 함수가 유틸리티 클래스보다 편한 이유입니다.
(자바 유틸리티 클래스를 확장 함수로 바꾸는 실전 이야기는
13부 38.5에서 다시 다룹니다.)
17.2 Extension Property
함수가 아니라 값처럼 쓰고 싶을 때
확장 함수가 “함수를 붙이는 것“이라면,
확장 프로퍼티(extension property)는 “값을 붙이는 것“입니다.
프로퍼티(property)는
“객체가 가진 값“을 뜻합니다.
(프로퍼티는 4부 7장에서 자세히 다룹니다.)
앞에서 만든 lastChar를 떠올려 봅시다.
"kotlin".lastChar() // 함수 호출: 괄호가 붙음
그런데 마지막 글자는
어떤 “동작“이라기보다
그 문자열이 “가진 값“에 가깝습니다.
그래서 괄호 없이,
값처럼 쓰고 싶을 수 있습니다.
"kotlin".lastChar // 이렇게 값처럼 쓰고 싶다
이럴 때 확장 프로퍼티를 씁니다.
확장 프로퍼티 만들기
lastChar를 확장 프로퍼티로 바꿔 보겠습니다.
val String.lastChar: Char
get() = this[length - 1]
이제 괄호 없이 쓸 수 있습니다.
val word = "kotlin"
println(word.lastChar) // 출력: n
코드를 뜯어보겠습니다.
val String.lastChar: String에lastChar라는 값을 붙인다: Char: 그 값의 타입은 Char다get() = ...: 이 값을 읽을 때 무엇을 돌려줄지 정한다
여기서 get() 부분을
게터(getter)라고 부릅니다.
게터는 “이 프로퍼티를 읽으면 이 값을 줘“라고
알려 주는 코드입니다.
(게터는 7장 7.3에서 자세히 배웁니다.)
확장 프로퍼티에는
반드시 이 게터를 적어 줘야 합니다.
확장 프로퍼티에 값을 저장할 수는 없다
여기서 중요한 규칙이 하나 있습니다.
확장 프로퍼티는 값을 따로 저장할 수 없습니다.
무슨 뜻일까요?
일반 프로퍼티는 값을 담아 둘 공간을 가집니다.
하지만 확장 프로퍼티는
기존 타입에 “끼워 넣은” 것일 뿐이라,
값을 담을 자기만의 공간이 없습니다.
그래서 아래처럼 초깃값을 주는 것은 안 됩니다.
val String.lastChar: Char = ' ' // 오류: 값을 저장할 수 없음
확장 프로퍼티는
“이미 있는 값들로부터 계산해서 돌려주는” 용도입니다.
그래서 항상 게터가 필요한 것입니다.
정리하면 이렇습니다.
| 구분 | 값을 저장 | 계산해서 돌려주기 |
|---|---|---|
| 일반 프로퍼티 | 가능 | 가능 |
| 확장 프로퍼티 | 불가능 | 가능 |
언제 함수, 언제 프로퍼티로 만들까
확장 함수와 확장 프로퍼티는 비슷해 보입니다.
어느 쪽으로 만드는 게 좋을까요?
간단한 기준은 이렇습니다.
- 그 대상의 “성질“이나 “상태“를 나타낸다 → 프로퍼티
- 어떤 “동작“이나 “일 처리“를 한다 → 함수
예를 들어 봅시다.
// 성질에 가깝다 → 프로퍼티
val String.lastChar: Char
get() = this[length - 1]
// 동작에 가깝다 → 함수
fun String.repeatWith(separator: String, times: Int): String {
return List(times) { this }.joinToString(separator)
}
애매하면 함수로 만들면 됩니다.
함수는 매개변수를 받을 수 있어
더 유연하기 때문입니다.
17.3 확장 함수의 실제 동작
확장 함수는 편리하지만,
“어떻게 동작하는지“를 오해하기 쉽습니다.
여기서 한번 정확히 짚고 넘어가겠습니다.
이 내용을 알아 두면 나중에 헷갈릴 일이 줄어듭니다.
클래스가 실제로 바뀌는 것은 아니다
String.lastChar()를 만들었다고 해서,
String 클래스 안에 진짜로 함수가 추가되는 것은 아닙니다.
String은 코틀린과 자바가
공유하는 기본 타입입니다.
우리가 함부로 뜯어고칠 수 없습니다.
그렇다면 확장 함수는 무엇일까요?
사실 확장 함수는
겉모습만 멤버 함수처럼 보이는
“편의 문법“에 가깝습니다.
컴파일러는 확장 함수를
아래와 같은 평범한 함수 호출로 바꿔서 처리합니다.
// 우리가 쓴 코드
"kotlin".lastChar()
// 컴파일러가 이해하는 방식 (개념적으로)
lastChar("kotlin") // "kotlin"을 첫 번째 인자로 넘긴 것과 같음
즉, 확장 함수는
“값을 첫 번째로 받는 일반 함수“를
점(.)으로 예쁘게 부를 수 있게 해 준 것입니다.
앞에서 본 자바 유틸리티 클래스와
속을 들여다보면 거의 같은 구조입니다.
겉으로는 멤버 함수처럼 보이지만,
속으로는 값을 넘겨 받는 일반 함수입니다.
정적 디스패치: 변수의 타입으로 결정된다
여기서 한 가지 중요한 성질이 나옵니다.
확장 함수는
“변수에 담긴 실제 값“이 아니라
“변수의 타입“을 보고 어떤 함수를 부를지 정합니다.
이것을
정적 디스패치(static dispatch)라고 부릅니다.
- 디스패치(dispatch) : 여러 함수 중 무엇을 부를지 고르는 일
- 정적(static) : 프로그램 실행 전, 컴파일 시점에 미리 정해짐
말로만 들으면 어렵습니다.
예제로 확인해 보겠습니다.
open class Animal
class Dog : Animal()
fun Animal.sound() = "어떤 동물의 소리"
fun Dog.sound() = "멍멍"
fun main() {
val animal: Animal = Dog()
println(animal.sound())
}
animal 변수에는
실제로는 Dog 값이 담겨 있습니다.
그러면 "멍멍"이 나올 것 같지만,
결과는 다릅니다.
어떤 동물의 소리
왜 그럴까요?
animal 변수의 타입이Animal로 선언되어 있기 때문입니다.
확장 함수는 변수에 담긴 실제 값(Dog)이 아니라,
변수의 타입(Animal)을 기준으로 고릅니다.
확장 함수는 “실제 값“이 아니라
“선언된 타입“을 보고 결정됩니다.
이 점은 다음 절에서 배울
멤버 함수와 정반대입니다.
꼭 기억해 두면 좋습니다.
(멤버 함수가 실제 값을 기준으로 동작하는 원리,
즉 오버라이딩은 4부 8장에서 자세히 다룹니다.)
17.4 Member Function과 Extension Function
멤버 함수가 무엇인가
지금까지는 확장 함수만 봤습니다.
이제 그 짝인 멤버 함수와 비교해 보겠습니다.
멤버 함수(member function)는
클래스 안에 직접 정의된 함수입니다.
즉, 그 클래스를 처음 만들 때부터
“원래 들어 있던” 함수입니다.
class Member(val name: String) {
// 클래스 안에 직접 정의 → 멤버 함수
fun greeting(): String {
return "안녕하세요, ${name}님"
}
}
반면 확장 함수는
클래스 밖에서 나중에 덧붙인 함수입니다.
// 클래스 밖에서 붙임 → 확장 함수
fun Member.shortName(): String {
return name.take(1)
}
둘 다 member.함수()처럼 똑같이 부릅니다.
겉으로는 구분이 안 됩니다.
val member = Member("홍길동")
println(member.greeting()) // 멤버 함수
println(member.shortName()) // 확장 함수
이름이 겹치면 멤버 함수가 이긴다
만약 멤버 함수와 확장 함수의
이름이 똑같으면 어떻게 될까요?
이 경우 항상 멤버 함수가 우선합니다.
class Member(val name: String) {
fun hello(): String {
return "멤버 함수 hello"
}
}
fun Member.hello(): String {
return "확장 함수 hello"
}
fun main() {
val member = Member("홍길동")
println(member.hello())
}
결과는 이렇습니다.
멤버 함수 hello
확장 함수는 조용히 무시됩니다.
클래스 원래 주인의 함수가 이기는 셈입니다.
같은 이름이면 멤버 함수가 언제나 우선합니다.
이 규칙에는 좋은 이유가 있습니다.
만약 확장 함수가 멤버 함수를 이긴다면,
누군가 확장 함수를 하나 추가한 것만으로
기존 클래스의 동작이 몰래 바뀔 수 있습니다.
그건 아주 위험합니다.
그래서 코틀린은 원래 주인을 보호합니다.
확장 함수의 한계
확장 함수는 밖에서 붙인 것이라
할 수 없는 일도 있습니다.
바로
클래스의 숨겨진(private) 내부 값에는
접근할 수 없다는 점입니다.
class Account {
private var balance: Int = 0 // 외부에 숨긴 값
fun deposit(amount: Int) {
balance += amount
}
}
fun Account.show() {
println(balance) // 오류: private 값에 접근 불가
}
balance는 클래스가 감춰 둔 값입니다.
(감추기, 즉 접근 제어는 7장 7.4에서 다룹니다.)
멤버 함수는 같은 집 안에 있으니
이 값을 볼 수 있습니다.
하지만 확장 함수는 밖에서 붙인 손님이라
감춰진 값까지는 볼 수 없습니다.
언제 멤버로, 언제 확장으로 만들까
두 가지를 어떻게 나눠 쓰면 좋을까요?
간단한 기준을 정리해 보겠습니다.
| 상황 | 추천 |
|---|---|
| 그 타입의 핵심 기능이다 | 멤버 함수 |
| 내부의 숨겨진 값을 다뤄야 한다 | 멤버 함수 |
| 내가 고칠 수 없는 남의 타입에 기능을 더한다 | 확장 함수 |
| 부가적인 편의 기능이다 | 확장 함수 |
| 공통 로직을 여러 곳에서 쓰고 싶다 | 확장 함수 |
핵심은 이렇습니다.
그 타입의 “본질“이면 멤버로,
“편의를 위한 덤“이면 확장으로 만듭니다.
17.5 백엔드 공통 로직 분리하기
이제 확장 함수를 백엔드에서 어떻게 쓰는지
실전에 가까운 예로 살펴보겠습니다.
백엔드 코드에는
여러 곳에서 반복되는 자잘한 처리가 많습니다.
이런 공통 로직을 확장 함수로 모아 두면
코드가 훨씬 깔끔해집니다.
문자열 검증
서버는 사용자가 보낸 값을 늘 검증해야 합니다.
그중에서도 이메일 형식 검사는 아주 흔합니다.
먼저 검증 로직을 확장 함수로 만들어 보겠습니다.
fun String.isValidEmail(): Boolean {
val emailRegex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
return this.matches(emailRegex)
}
Regex는
정규식(regular expression)을 다루는 타입입니다.
“이런 형태의 글자인지” 검사하는 규칙이라고
이해하면 됩니다.
이제 사용하는 쪽은 아주 읽기 쉬워집니다.
val email = "user@example.com"
if (email.isValidEmail()) {
println("올바른 이메일입니다")
} else {
println("이메일 형식이 아닙니다")
}
올바른 이메일입니다
정규식이라는 복잡한 코드를isValidEmail()이라는 이름 뒤에 숨겼습니다.
읽는 사람은 규칙의 속을 몰라도
“이메일이 올바른지 검사하는구나“를
바로 알 수 있습니다.
날짜 변환
백엔드에서는 날짜와 시간을 자주 다룹니다.
자바의 LocalDate, LocalDateTime 타입을 많이 씁니다.
이 타입들을 특정 형식의 문자열로 바꾸는 일이
아주 자주 반복됩니다.
이 변환도 확장 함수로 정리할 수 있습니다.
import java.time.LocalDate
import java.time.format.DateTimeFormatter
fun LocalDate.toKoreanFormat(): String {
val formatter = DateTimeFormatter.ofPattern("yyyy년 M월 d일")
return this.format(formatter)
}
LocalDate는 우리가 만든 타입이 아닙니다.
자바가 제공하는 타입입니다.
그런데도 확장 함수 덕분에
마치 원래 있던 기능처럼 함수를 붙일 수 있습니다.
val today = LocalDate.of(2026, 8, 8)
println(today.toKoreanFormat())
2026년 8월 8일
여러 곳에서 날짜를 한국식으로 보여 줘야 할 때,
매번 DateTimeFormatter를 만들 필요가 없습니다.toKoreanFormat() 한 번이면 됩니다.
DTO 변환
백엔드에서 확장 함수가 가장 빛나는 곳은
DTO 변환입니다.
DTO는 Data Transfer Object,
우리말로 “데이터 전달 객체“입니다.
(DTO는 4부 9장에서 자세히 배웁니다.)
간단히 말하면 이렇습니다.
서버 안에서 쓰는 객체와,
바깥으로 내보내는 객체는 보통 다릅니다.
그래서 둘 사이를 변환하는 코드가 늘 필요합니다.
예를 들어 봅시다.
서버 내부에서 쓰는 회원 객체와,
화면으로 내보내는 응답 객체가 있다고 하겠습니다.
// 서버 내부에서 쓰는 객체
data class Member(
val id: Long,
val name: String,
val email: String,
val password: String
)
// 바깥으로 내보내는 응답 객체
data class MemberResponse(
val id: Long,
val name: String,
val email: String
)
응답 객체에는password가 빠져 있습니다.
비밀번호를 밖으로 내보내면 안 되기 때문입니다.
이제 Member를 MemberResponse로 바꾸는
변환 함수를 확장 함수로 만들어 보겠습니다.
fun Member.toResponse(): MemberResponse {
return MemberResponse(
id = this.id,
name = this.name,
email = this.email
)
}
사용하는 쪽은 이렇게 깔끔합니다.
val member = Member(1L, "홍길동", "hong@example.com", "secret")
val response = member.toResponse()
println(response)
MemberResponse(id=1, name=홍길동, email=hong@example.com)
변환 로직이 toResponse() 안에 모여 있어,
여러 곳에서 같은 방식으로 안전하게 변환할 수 있습니다.
만약 나중에 응답에 담을 값이 바뀌더라도
이 함수 한 곳만 고치면 됩니다.
(이 DTO 변환 패턴은 11부 33.8에서
더 본격적으로 다룹니다.)
확장 함수를 어디에 둘까
확장 함수는 클래스 밖,
파일의 톱레벨에 둘 수 있습니다.
(톱레벨 함수는 1장 1.4에서 설명했습니다.)
관련된 확장 함수들을
하나의 파일에 모아 두면 관리하기 좋습니다.
예를 들어 이런 식으로 나눕니다.
StringExtensions.kt: 문자열 관련 확장 함수DateExtensions.kt: 날짜 관련 확장 함수MemberMapper.kt: 회원 DTO 변환 함수
다른 파일에서 이 확장 함수를 쓰려면import로 가져오면 됩니다.
IntelliJ IDEA가 대부분 자동으로 넣어 줍니다.
한 가지 주의할 점이 있습니다.
확장 함수를 너무 많이 만들면
오히려 코드가 어디 있는지 찾기 어려워집니다.
정말 여러 곳에서 반복되는 로직만
확장 함수로 뽑아내는 것이 좋습니다.
17장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 확장 함수는 기존 타입에
마치 원래 있던 것처럼 함수를 붙이는 기능이라는 점 - 함수 이름 앞의 수신 객체 타입과,
함수 안에서 자신을 가리키는this의 의미 - 확장 프로퍼티는 값을 저장하지 못하고,
계산해서 돌려주는 게터가 필요하다는 점 - 확장 함수는 클래스를 실제로 바꾸지 않으며,
변수의 타입으로 결정되는 정적 디스패치를 따른다는 점 - 이름이 겹치면 멤버 함수가 언제나 우선하고,
확장 함수는 숨겨진 값에 접근할 수 없다는 점 - 문자열 검증, 날짜 변환, DTO 변환 같은
백엔드 공통 로직을 확장 함수로 깔끔하게 분리하는 방법
확장 함수는 자바의 유틸리티 클래스를
훨씬 자연스러운 모습으로 바꿔 줍니다.
값을 먼저 쓰고, 하고 싶은 일을 뒤에 붙이는
이 순서가 코틀린다운 코드의 시작입니다.
다음 장에서는
연산자와 코틀린의 관례(convention)를 다뤄 보겠습니다.